feat: added Web Push API support (#3845)#3950
Conversation
📝 WalkthroughWalkthroughAdds end-to-end Web Push: service worker, backend WebPush service and repository, frontend and admin APIs and UI, DB table and migration, DI wiring, VAPID key generation, client subscription flows, translations, tests, and nginx/ESLint edits to serve the service worker. Changes
Sequence DiagramssequenceDiagram
participant Browser as Browser/User
participant Frontend as Frontend App
participant SW as Service Worker
participant API as Backend API
participant DB as Database
Browser->>Frontend: page load (DOMContentLoaded)
Frontend->>API: GET /push/vapid-public-key
API-->>Frontend: { enabled, vapidPublicKey }
Frontend->>SW: register('/sw.js')
Frontend->>Browser: request Notification permission
Browser-->>Frontend: permission granted
Frontend->>SW: PushManager.subscribe()
SW-->>Frontend: subscription object
Frontend->>API: POST /push/subscribe (subscription)
API->>DB: save subscription
DB-->>API: OK
API-->>Frontend: { success: true }
sequenceDiagram
participant Admin as Admin User
participant AdminUI as Admin UI
participant AdminAPI as Admin API
participant Config as Configuration Store
participant WebPush as WebPushService
participant Subs as Subscribers
Admin->>AdminUI: click "Generate VAPID Keys"
AdminUI->>AdminAPI: POST /admin/api/push/generate-vapid-keys
AdminAPI->>WebPush: generateVapidKeys()
WebPush-->>AdminAPI: { publicKey, privateKey }
AdminAPI->>Config: store keys and subject
Config-->>AdminAPI: OK
AdminAPI-->>AdminUI: { success, publicKey }
Note over WebPush,Subs: Later, when new active FAQ is created
WebPush->>Subs: send notifications to endpoints
Subs->>SW: push events delivered
SW->>Browser: show notifications
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
tests/phpMyFAQ/Faq/PermissionTest.php (1)
58-110:⚠️ Potential issue | 🟡 MinorMissing test for the "permission already exists" (skip INSERT) path.
The core new behavior added in
Permission::add()is skipping the INSERT when the permission already exists. None of the updated tests cover this branch — all tests mocknumRowsto return0. Add a test wherenumRowsreturns> 0and verify that only the SELECT query is issued (no INSERT).Suggested test
public function testAddSkipsInsertWhenPermissionAlreadyExists(): void { $resultMock = $this->createMock(\SQLite3Result::class); // Only 1 query expected: the SELECT check (INSERT should be skipped) $this->dbMock ->expects($this->once()) ->method('query') ->with($this->stringContains('SELECT 1')) ->willReturn($resultMock); $this->dbMock ->expects($this->once()) ->method('numRows') ->willReturn(1); // Permission already exists $result = $this->permission->add(Permission::USER, 123, [456]); $this->assertTrue($result); }phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php (1)
38-41:⚠️ Potential issue | 🟡 MinorMigration description is stale — doesn't mention push subscriptions.
getDescription()lists "Admin log hash columns, custom pages, chat messages, translation config" but the migration now also creates the push subscriptions table and adds web push config entries.Suggested update
public function getDescription(): string { - return 'Admin log hash columns, custom pages, chat messages, translation config'; + return 'Admin log hash columns, custom pages, chat messages, translation config, push subscriptions'; }
🤖 Fix all issues with AI agents
In `@composer.json`:
- Line 33: Update the composer.json require section so it explicitly lists the
PHP extensions required by minishlink/web-push v9.0: add "ext-mbstring" and move
"ext-openssl" from suggest into require (leave "ext-json" as-is since it is
already required); keep ext-gmp and ext-bcmath only as optional (suggest) if
present. Ensure the composer.json "require" object contains "ext-mbstring" and
"ext-openssl" alongside existing PHP and ext-json entries.
In `@phpmyfaq/admin/assets/src/api/push.ts`:
- Around line 24-33: The fetchGenerateVapidKeys function must accept a csrfToken
parameter and include it in the POST body/headers like other admin API calls;
update the function signature fetchGenerateVapidKeys(csrfToken: string) and add
the token to the fetch request (consistent with deleteAttachments/deleteCategory
usage) so the client sends the CSRF token, and update the server-side
PushController::generateVapidKeys() to call
Token::getInstance($this->session)->verifyToken() to validate the token before
generating/storing VAPID keys.
In `@phpmyfaq/assets/src/push/index.ts`:
- Around line 237-238: The service worker registration uses a hardcoded root
path (navigator.serviceWorker.register('/sw.js')) which will 404 for installs
under a subdirectory; change phpmyfaq/assets/src/push/index.ts to compute the SW
URL from the app base (e.g., read a <base href="..."> or a meta tag you add for
the app root) and then call navigator.serviceWorker.register(swUrl) instead of
'/sw.js'. Use a robust URL join (e.g., new URL('sw.js', baseHref) or equivalent)
so the created swUrl works whether the app is at domain root or a subdirectory;
update the const registration assignment to use that computed swUrl.
- Around line 45-61: The showToast function currently injects the untrusted
message string via innerHTML causing XSS; change it to build the alert DOM
programmatically: create the alert div (alert.className, role), append a text
node or set textContent for the message instead of interpolating into innerHTML,
create the close button element (set type, classList, data-bs-dismiss and
aria-label) and append it to the alert, then append the alert to the container
and keep the existing timeout logic that removes the show class and then the
element; update the function name showToast to use textContent/createTextNode
for message and avoid any innerHTML usage.
In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php`:
- Around line 262-280: Duplicate web-push notifications are caused by both
FaqController calling sendToAll(...) and Notification::sendNewFaqAdded() calling
sendWebPush(); pick one call site to remove: if you want end-users to receive
the public FAQ URL, remove the sendWebPush() call inside
Notification::sendNewFaqAdded() (which currently builds the admin edit URL);
otherwise, if you want admins to receive the admin URL via Notification, delete
the direct sendToAll(...) block in FaqController (the block that builds $faqUrl
with TitleSlugifier::slug(...) and calls $webPushService->sendToAll(...));
ensure the remaining code still triggers exactly one push and update any tests
or comments accordingly.
- Around line 262-280: Wrap the WebPushService lookup and sendToAll call in a
try/catch similar to the email notification block to prevent exceptions from
aborting the response: around the
container->get('phpmyfaq.push.web-push-service') and
$webPushService->sendToAll(...) in the FaqController, catch Throwable/Exception,
log the error (using the same logger used for emails) and continue so the
success response is still returned; ensure the catch references the exception
when logging to aid debugging.
In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php`:
- Around line 34-53: Add CSRF verification and robust error handling to
generateVapidKeys(): before proceeding, perform the same CSRF token check used
by other admin POST endpoints (see FaqController::create) and return a JSON
error if the token is invalid; wrap the calls to
WebPushService::generateVapidKeys() and $this->configuration->update(...) in a
try/catch (catch \Throwable) and on error return $this->json(['success' =>
false, 'message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
and fix the subject check to handle null by using
($this->configuration->get('push.vapidSubject') ?? '') === '' so missing keys
are treated like empty strings. Ensure success still returns $this->json([...],
Response::HTTP_OK) as before.
In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php`:
- Around line 120-125: The pushEnabled expression in AbstractFrontController
uses $this->configuration->get('push.vapidPublicKey') !== '' which treats null
as not equal to '' and can incorrectly enable push; update the check for the
'push.vapidPublicKey' value retrieved via Configuration::get() to be defensive
(e.g., ensure it's not null and not an empty string or use a non-empty-string
check) when computing the 'pushEnabled' value so push is only enabled when a
valid VAPID key exists.
In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php`:
- Around line 37-47: getVapidPublicKey is currently unauthenticated but uses a
private route name; update the Route attribute on the getVapidPublicKey() method
to follow frontend naming conventions by replacing
"api.private.push.vapid-public-key" with "public.push.vapid-public-key" (or if
you decide it must be private instead, add a call to
$this->userIsAuthenticated() at the start of getVapidPublicKey() and keep the
existing name), ensuring the route name change is reflected wherever this route
is referenced so naming is consistent with other push endpoints.
- Around line 55-87: The json_decode calls in PushController::subscribe and
PushController::unsubscribe use JSON_THROW_ON_ERROR and can throw \JsonException
which currently bubbles to a 500; wrap each json_decode in a try/catch that
catches \JsonException and returns $this->json(['error' => 'Invalid JSON
payload'], Response::HTTP_BAD_REQUEST) so malformed input yields a 400, leaving
the rest of the existing validation and repository logic unchanged (update the
error handling around the Request $request parsing in both subscribe and
unsubscribe methods).
In `@phpmyfaq/src/phpMyFAQ/Faq/Permission.php`:
- Around line 69-72: The code calls
$this->configuration->getDb()->query($checkQuery) and then immediately passes
the result to $this->configuration->getDb()->numRows(...), which can throw if
query() returns false; update the Permission class to check the query result
before using it: after calling $this->configuration->getDb()->query($checkQuery)
verify the return is not false (or is a valid result resource/object), log or
handle the DB error and skip/continue if the check failed, then only call
numRows when safe; apply the same defensive check pattern to the subsequent
INSERT query call (the code that constructs and executes the INSERT) to avoid
passing false into other DB helper methods.
In `@phpmyfaq/src/phpMyFAQ/Notification.php`:
- Around line 135-146: The current Notification::sendWebPush call builds and
sends an admin-only URL ('admin/faq/edit/{id}/{lang}') to all subscribers via
sendWebPush/sendToAll, which causes non-admin users to receive an unusable link
and duplicates the public push sent by FaqController; update Notification to
target only admins (use WebPushService::sendToUser or an admin-filtered
audience) or replace the href with the public FAQ URL used in FaqController so
only appropriate recipients get the admin edit link; locate the sendWebPush
invocation in Notification and either switch to
WebPushService::sendToUser/sendToAdmins (or equivalent audience filter) or
construct the public-facing URL before sending.
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 46-49: The DB query result isn't checked for false before calling
$db->fetchObject, which can cause a TypeError; in PushSubscriptionRepository
update each method that does "$result = $db->query($query); $existing =
$db->fetchObject($result);" (e.g., the methods handling endpoint lookup,
getByUserId, getAll, and hasSubscription) to first verify $result !== false and
handle the error by returning the appropriate empty value (null for
single-object getters, [] for getAll, false for hasSubscription) or
logging/throwing as the repository's error policy dictates; ensure the check
happens immediately after $db->query(...) and before any call to
$db->fetchObject(...) or $db->fetchObjectAll(...).
In `@phpmyfaq/src/phpMyFAQ/Push/WebPushService.php`:
- Around line 39-50: The isEnabled() method incorrectly treats null VAPID keys
as present because it uses strict !== '' checks; update isEnabled() (in
WebPushService::isEnabled) to treat unset/null/empty values as absent by using
empty() (or equivalent) for 'push.vapidPublicKey' and 'push.vapidPrivateKey'
checks, and also normalize/guard the 'push.enableWebPush' value (currently
$enableWebPush) to handle null in the boolean/string check so the method only
returns true when enableWebPush is truthy and both VAPID keys are non-empty.
In `@phpmyfaq/sw.js`:
- Around line 53-70: The notificationclick handler currently compares client.url
to a possibly relative event.notification.data?.url (default '/'), which never
matches; resolve the payload URL against the service worker origin before
comparison and before opening: create a resolvedUrl = new
URL(event.notification.data?.url || '/', self.location.origin) and compare
client.url to resolvedUrl.href (and pass resolvedUrl.href to
self.clients.openWindow). Update the code in the notificationclick listener
where client.url and self.clients.openWindow are used (the event handler
anonymous function) to use the resolved absolute URL.
🧹 Nitpick comments (19)
phpmyfaq/src/phpMyFAQ/Faq/Permission.php (1)
58-84: Consider reducing query count for large ID arrays.The current approach issues 2 queries per ID (SELECT + INSERT). For bulk operations (e.g., 100 IDs → 200 queries), this can be costly. A single SELECT to fetch all existing
(record_id, {mode}_id)pairs for the given$faqIdupfront, followed by INSERTs only for missing IDs, would cut the query count significantly.Sketch: batch the existence check
foreach ($ids as $id) { - // Check if permission already exists to avoid duplicate key errors - $checkQuery = sprintf( - 'SELECT 1 FROM %sfaqdata_%s WHERE record_id = %d AND %s_id = %d', - Database::getTablePrefix(), - $mode, - $faqId, - $mode, - $id, - ); - - $result = $this->configuration->getDb()->query($checkQuery); - if ($this->configuration->getDb()->numRows($result) > 0) { - continue; // Permission already exists, skip - } - $query = sprintf( 'INSERT INTO %sfaqdata_%s (record_id, %s_id) VALUES (%d, %d)', Database::getTablePrefix(),Replace with a single upfront query:
// Fetch existing IDs in one query $existingIds = []; $checkQuery = sprintf( 'SELECT %s_id FROM %sfaqdata_%s WHERE record_id = %d', $mode, Database::getTablePrefix(), $mode, $faqId, ); $result = $this->configuration->getDb()->query($checkQuery); if ($result && $this->configuration->getDb()->numRows($result) > 0) { while ($row = $this->configuration->getDb()->fetchObject($result)) { $existingIds[] = (int) $row->{$mode . '_id'}; } } foreach ($ids as $id) { if (in_array($id, $existingIds, true)) { continue; } // ... INSERT ... }tests/phpMyFAQ/Entity/PushSubscriptionEntityTest.php (1)
112-132:testFluentInterfaceomits assertions forendpointHashandcreatedAt.The test chains all setters but only asserts a subset of fields. Consider adding the missing assertions for completeness:
💡 Proposed addition
$this->assertEquals('authtoken', $this->entity->getAuthToken()); $this->assertEquals('aesgcm', $this->entity->getContentEncoding()); + $this->assertEquals(hash('sha256', 'https://example.com/push'), $this->entity->getEndpointHash()); + $this->assertEquals($date, $this->entity->getCreatedAt()); }nginx.conf (1)
82-89: Consider usingdefault_typeinstead ofadd_header Content-Type.The Docker nginx config (
.docker/nginx/default.conf) usesdefault_type application/javascriptfor the same/sw.jsblock, which is the correct approach. Usingadd_header Content-Typecan result in a duplicateContent-Typeheader alongside the one nginx derives from its MIME type mapping, sinceadd_headerappends response headers rather than replacing core ones.Also note that
add_headerin a location block suppresses all inheritedadd_headerdirectives (e.g., theX-Frame-Options SAMEORIGINon Line 73). This is fine for a service worker script but worth being aware of.Align with the Docker nginx config
# Service Worker - no caching, proper headers location = /sw.js { - add_header Content-Type "application/javascript; charset=utf-8"; + default_type application/javascript; add_header Service-Worker-Allowed "/"; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header X-Content-Type-Options "nosniff"; try_files $uri =404; }phpmyfaq/admin/assets/src/api/push.test.ts (1)
12-17:vi.restoreAllMocks()won't restoreglobalThis.fetchafter direct assignment.Directly assigning
globalThis.fetch = vi.fn(...)meansrestoreAllMocks()on Line 6 cannot restore the original reference — it only restores spies created viavi.spyOn. Consider usingvi.spyOninstead, or save/restore the original inbeforeEach/afterEach.Use vi.spyOn for proper restoration
- globalThis.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response);And similarly for the error test:
- const mockError = new Error('Fetch failed'); - globalThis.fetch = vi.fn(() => Promise.reject(mockError)); + const mockError = new Error('Fetch failed'); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(mockError);tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php (1)
39-51: Consider asserting the new table name intestGetTableNamesReturnsCorrectNames.The test verifies specific table names like
faqchat_messagesandfaqcustompagesbut doesn't includefaqpush_subscriptions. Adding it would keep coverage consistent with the other recently added tables.Suggested addition
$this->assertContains('faqchat_messages', $names); $this->assertContains('faqcustompages', $names); $this->assertContains('faqseo', $names); + $this->assertContains('faqpush_subscriptions', $names);phpmyfaq/assets/templates/default/index.twig (1)
231-248: Push opt-in banner looks well-structured.Good use of
d-nonedefault with progressive JS enhancement. Gating on bothpushEnabledandisUserLoggedInis correct — push subscriptions should only be offered to authenticated users.One minor note: the inline
z-index: 1050matches Bootstrap's modal layer. If a modal opens while the banner is visible, they could visually overlap. Consider usingz-index: 1040(below modals) or a utility class to avoid potential stacking conflicts.phpmyfaq/assets/src/api/push.test.ts (1)
1-4:subscribePushis not covered by any test.The production module exports
subscribePush(per the AI summary), but it is neither imported nor tested here. Consider adding a test for the subscribe flow — it's the most important endpoint in the push lifecycle.phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php (1)
33-33: Route action name uses hyphens instead of dots.Other admin API routes use dot-separated names (e.g.,
admin.api.faq.create,admin.api.faqs.sticky.order). This route usesadmin.api.push.generate-vapid-keyswith hyphens in the action segment. Consideradmin.api.push.generateVapidKeysor similar for consistency.Based on learnings: "Admin API routes should follow the naming convention: admin.api.{resource}.{action}"
tests/phpMyFAQ/Push/PushSubscriptionRepositoryTest.php (1)
70-98: Consider adding tests with actual subscription results.
getByUserIdandgetAllare only tested for the empty-array case. ThemapRowToEntitymapping logic (which hydratesPushSubscriptionEntityfrom a DB row) is never exercised. A test withfetchObjectreturning a mock row object would catch mapping bugs.phpmyfaq/admin/assets/src/configuration/webpush.ts (1)
62-67: Static analysis innerHTML warnings are false positives here — all values are hardcoded string literals with no user-controlled input. No XSS risk.However, the button label and notification messages (Lines 66, 73, 85, 87, 90, 93) are English-only hardcoded strings. If the admin interface supports localization, these should go through the translation system.
phpmyfaq/src/phpMyFAQ/Entity/PushSubscriptionEntity.php (1)
26-40: Properties are public despite having getters/setters.All properties are declared
publicwhile the class provides getters and setters. This allows bypassing the setter API (e.g.,$entity->authToken = 'x'skips the#[\SensitiveParameter]annotation context). Consider usingprivateorprotectedvisibility to enforce use of the setter API, especially for sensitive fields likeauthToken.♻️ Suggested visibility change
class PushSubscriptionEntity { - public int $id; + private int $id; - public int $userId; + private int $userId; - public string $endpoint; + private string $endpoint; - public string $endpointHash; + private string $endpointHash; - public string $publicKey; + private string $publicKey; - public string $authToken; + private string $authToken; - public ?string $contentEncoding; + private ?string $contentEncoding; - public DateTimeImmutable $createdAt; + private DateTimeImmutable $createdAt;phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (1)
70-77: RedundantendpointHashcomputation — repository recomputes it.The entity's
endpointHashis set here (Line 74), butPushSubscriptionRepository::save()(seePushSubscriptionRepository.phpLine 43) recomputes it from the endpoint, ignoring the entity's hash. This is confusing and error-prone if the two diverge. Consider either removing the hash computation here or making the repository use the entity's hash.phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)
123-160: Duplicated fetch-loop logic ingetByUserIdandgetAll.Both methods repeat the same
while ($row) { ... $row = $db->fetchObject($result); }pattern. Consider extracting a private helper to reduce duplication.tests/phpMyFAQ/Push/WebPushServiceTest.php (1)
38-62: Mock returns'true'/'false'strings, but realConfiguration::get()converts them to booleans.Looking at
Configuration::get()(from the relevant snippets), it uses amatchto convert'true'→trueand'false'→false. So in production,isEnabled()will receive a boolean, not a string. TheisEnabled()method handles both (=== 'true' || === true), so these tests pass—but they exercise the string-comparison branch that won't occur in practice. Consider using boolean return values in the mock to better reflect real behavior.phpmyfaq/assets/src/api/push.ts (2)
82-99:unsubscribePushreusesPushSubscribeResponseas return type.The return type
PushSubscribeResponseis semantically misleading for an unsubscribe operation. Consider introducing a sharedPushActionResponsetype (or similar) to avoid the naming confusion.
30-46: UnnecessaryContent-Type: application/jsonheader on GET requests.Lines 34–36 (and similarly Lines 105–107 in
getPushStatus) set aContent-Typeheader on GET requests that have no body. This is harmless but unnecessary.phpmyfaq/assets/src/push/index.ts (2)
144-153: Consider reversing unsubscribe order: notify server before local unsubscribe.If
unsubscribePush(endpoint)fails aftercurrentSubscription.unsubscribe()succeeds (line 147–148), the browser is unsubscribed but the server retains a stale record. While the server will eventually purge it on the next send attempt, reversing the order would let you abort cleanly on server failure without orphaning the local subscription.♻️ Suggested reorder
if (isSubscribed && currentSubscription) { const endpoint = currentSubscription.endpoint; - await currentSubscription.unsubscribe(); await unsubscribePush(endpoint); + await currentSubscription.unsubscribe(); isSubscribed = false;
229-235: Fragile DOM traversal:parentElement?.parentElement?is brittle.Line 231 walks up two parent nodes to hide the UCP section. Any template change will silently break this. Prefer
closest()with a known selector.♻️ Suggested fix
if (!vapidResponse.enabled || !vapidResponse.vapidPublicKey) { if (ucpButton) { - ucpButton.parentElement?.parentElement?.classList.add('d-none'); + ucpButton.closest('.pmf-push-toggle-container')?.classList.add('d-none'); }Add the
.pmf-push-toggle-containerclass (or a suitable selector) to the wrapping element in the template.phpmyfaq/src/phpMyFAQ/Push/WebPushService.php (1)
114-124: VAPID subject is fetched from config twice; extract to a local variable.
$this->configuration->get('push.vapidSubject')is called on both lines 117 and 119. A local variable is cleaner and avoids the double lookup.♻️ Suggested simplification
$auth = [ 'VAPID' => [ - 'subject' => - $this->configuration->get('push.vapidSubject') !== '' - && $this->configuration->get('push.vapidSubject') !== null - ? $this->configuration->get('push.vapidSubject') - : 'mailto:' . $this->configuration->getAdminEmail(), + 'subject' => !empty($vapidSubject = $this->configuration->get('push.vapidSubject')) + ? $vapidSubject + : 'mailto:' . $this->configuration->getAdminEmail(), 'publicKey' => $this->configuration->get('push.vapidPublicKey'), 'privateKey' => $this->configuration->get('push.vapidPrivateKey'), ], ];Or, for readability:
$vapidSubject = $this->configuration->get('push.vapidSubject'); $auth = [ 'VAPID' => [ 'subject' => !empty($vapidSubject) ? $vapidSubject : 'mailto:' . $this->configuration->getAdminEmail(), // ... ], ];
phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php`:
- Around line 66-70: In the catch block inside PushController (the catch
(\Throwable $e) in phpMyFAQ/Controller/Administration/Api/PushController.php)
stop returning $e->getMessage() directly; instead log the full exception (use
the controller's logger or error_log() to record $e, including stack trace) and
return a generic API error payload like 'An internal error occurred' with the
same HTTP_INTERNAL_SERVER_ERROR status; ensure the logged entry includes
$e->getMessage() and $e->getTraceAsString() so developers can debug without
leaking internals to clients.
In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php`:
- Around line 94-120: The unsubscribe action currently calls
PushSubscriptionRepository::deleteByEndpointHash allowing any authenticated user
to remove arbitrary subscriptions; add a new repository method
deleteByEndpointHashAndUserId(string $endpointHash, int $userId): bool in
PushSubscriptionRepository that performs the DELETE scoped by endpoint_hash AND
user_id, then update the Frontend\Api\PushController::unsubscribe to obtain the
current user id after userIsAuthenticated() (e.g. from the authenticated user
object/session) and call deleteByEndpointHashAndUserId($endpointHash, $userId)
instead of deleteByEndpointHash to enforce per-user authorization.
In `@phpmyfaq/src/phpMyFAQ/Notification.php`:
- Around line 310-317: Replace the hardcoded superadmin user id check in
Notification::(/* around adminUserIds usage */) with a lookup for users whose
is_superadmin flag is set: add or use a User class helper (e.g.
User::getSuperAdminIds() or User::getSuperAdmins()) that queries the users table
for is_superadmin = 1 and returns their IDs, then merge those IDs into
$adminUserIds instead of adding literal 1; also search for the same pattern in
UserController (the delete protection around user id 1) and replace the
hardcoded check with a call to the same helper to protect any user returned as a
superadmin.
🧹 Nitpick comments (7)
tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php (1)
338-365: Formatting looks good; consider aligning the sibling test for consistency.The multi-line chain style on lines 355–360 is an improvement for readability. However, the nearly identical chain in
testSqliteAutoIncrementDoesNotDuplicatePrimaryKey(line 341) is still on a single line. Consider reformatting that one as well so the two related tests are visually consistent.Also, the trailing comment on line 360 (
// This should be ignored when autoIncrement is used) describes the behavior of->primaryKey('id'), not->build(). Moving it to a standalone line above the chain (or next to->primaryKey('id')) would be clearer.♻️ Suggested alignment with sibling test
public function testSqliteAutoIncrementDoesNotDuplicatePrimaryKey(): void { $sqliteBuilder = new TableBuilder(new SqliteDialect()); - $sql = $sqliteBuilder->table('test', false)->autoIncrement('id')->varchar('name', 100)->build(); + $sql = $sqliteBuilder + ->table('test', false) + ->autoIncrement('id') + ->varchar('name', 100) + ->build();public function testAutoIncrementSkipsExplicitPrimaryKey(): void { $sqliteBuilder = new TableBuilder(new SqliteDialect()); + // primaryKey('id') should be ignored when autoIncrement is used $sql = $sqliteBuilder ->table('test', false) ->autoIncrement('id') ->varchar('name', 100) ->primaryKey('id') - ->build(); // This should be ignored when autoIncrement is used + ->build();phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php (1)
269-276: Duplicated FAQ URL construction pattern.The
sprintfpattern for building the public FAQ URL is identical to lines 202–208 (open question answered flow). Consider extracting a small helper to avoid maintaining the same URL template in two places within this method.♻️ Suggested helper extraction
+ // Helper to build public FAQ URL (could also be a method on FaqEntity or a utility) + $buildFaqUrl = static fn (int $categoryId, FaqEntity $faq, string $baseUrl): string => sprintf( + '%scontent/%d/%d/%s/%s.html', + $baseUrl, + $categoryId, + $faq->getId(), + $faq->getLanguage(), + TitleSlugifier::slug($faq->getQuestion()), + );Then replace both occurrences (lines 202–208 and 269–275) with:
$faqUrl = $buildFaqUrl($categories[0], $faqData, $this->configuration->getDefaultUrl());phpmyfaq/admin/assets/src/configuration/webpush.ts (1)
86-86: Consider i18n for user-facing strings.The notification messages ("VAPID keys have been generated successfully", "Failed to generate VAPID keys", "Generate VAPID Keys", "Generating...") are hardcoded in English. If the admin backend supports localization, these should use the translation system.
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (1)
52-52: Authenticated endpoint route names useapi.private.*instead of the frontend convention.Per coding guidelines, frontend routes should follow
public.{resource}.{action}. These three authenticated routes use theapi.private.push.*pattern. Consider aligning them — e.g.public.push.subscribe,public.push.unsubscribe,public.push.status— or establishing a clearprivate.{resource}.{action}convention for authenticated frontend API routes. As per coding guidelines, "Frontend routes should follow the naming convention: public.{resource}.{action}".Also applies to: 94-94, 125-125
phpmyfaq/src/phpMyFAQ/Push/WebPushService.php (1)
125-174:sendToSubscriptionsis well-structured overall.Good use of the try/catch around the entire notification flow, expired-endpoint cleanup via flush reports, and the fallback to
getAdminEmail()for the VAPID subject. A couple of minor observations:
- The
push.vapidSubjectconfig value is fetched three times (Lines 135, 136, 137). Assigning it to a local variable would be cleaner and slightly more efficient.- Notification sending is synchronous; for large subscriber counts, consider documenting that this should be dispatched to a background job. Not blocking for now, but worth noting for future scale.
♻️ Minor: cache the config lookup
+ $vapidSubject = $this->configuration->get('push.vapidSubject'); $auth = [ 'VAPID' => [ 'subject' => - $this->configuration->get('push.vapidSubject') !== '' - && $this->configuration->get('push.vapidSubject') !== null - ? $this->configuration->get('push.vapidSubject') + !empty($vapidSubject) + ? $vapidSubject : 'mailto:' . $this->configuration->getAdminEmail(),phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (2)
40-89: TOCTOU race in the upsert: concurrent requests for the same endpoint can cause a duplicate-key failure.The
save()method performs a SELECT to check existence, then either UPDATEs or INSERTs. Between these two queries, a concurrent request with the same endpoint could insert a row, causing the second INSERT to fail (assuming a unique index onendpoint_hash). For push subscriptions the practical risk is low, but be aware this can surface under load.A database-level
INSERT … ON DUPLICATE KEY UPDATE(MySQL) orINSERT … ON CONFLICT(PostgreSQL) would be the robust fix, though the multi-DB abstraction layer may not support it. At minimum, consider wrapping the check-and-write in a transaction.
127-146: Duplicated fetch-loop pattern across three methods.
getByUserId,getByUserIds, andgetAllshare identical row-fetching logic (query → check false → while-loop → mapRowToEntity). Consider extracting a private helper likefetchSubscriptions(string $query): array.♻️ Proposed helper extraction
+ /** + * `@return` PushSubscriptionEntity[] + */ + private function fetchSubscriptions(string $query): array + { + $db = $this->configuration->getDb(); + $result = $db->query($query); + if ($result === false) { + return []; + } + + $subscriptions = []; + $row = $db->fetchObject($result); + while ($row) { + $subscriptions[] = $this->mapRowToEntity($row); + $row = $db->fetchObject($result); + } + + return $subscriptions; + }Then each method simplifies to building the query string and calling
return $this->fetchSubscriptions($query);.Also applies to: 154-178, 185-204
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 239-253: In mapRowToEntity, constructing DateTimeImmutable from
$row->created_at can throw on malformed data; wrap the parse in a defensive
block: either use DateTimeImmutable::createFromFormat with the expected DB
datetime format and verify the result, or wrap new
DateTimeImmutable($row->created_at) in a try/catch that catches \Exception and
provides a safe fallback (e.g. null or a default DateTimeImmutable) before
calling $entity->setCreatedAt, and optionally log the parse error so corrupted
created_at values don't raise unhandled exceptions.
🧹 Nitpick comments (7)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (3)
40-89:save()recomputesendpointHashbut ignores the value already set on the entity.Line 43 computes
$endpointHash = hash('sha256', $entity->getEndpoint()), which is correct for the DB operation. However, this means the entity's ownendpointHashproperty (set by the caller viasetEndpointHash()) is silently ignored. If a caller sets a different hash on the entity, it will be overwritten in the DB without any indication. Consider either (a) always using$entity->getEndpointHash()(trusting the caller), or (b) setting the computed hash back on the entity so it stays consistent after save.Option (b) — keep entity in sync
$endpointHash = hash('sha256', $entity->getEndpoint()); + $entity->setEndpointHash($endpointHash);
202-221:getAll()fetches every subscription without a LIMIT.If the subscriber base grows large, this will load all rows into memory in a single query. This is used by
WebPushServiceto send notifications, so it could become a bottleneck. Consider adding pagination or a batched iterator, or at minimum a configurable upper bound.
144-163: Duplicated fetch-loop pattern across three methods.
getByUserId,getByUserIds, andgetAlleach repeat the identicalwhile ($row) { … fetchObject }loop. A small private helper would reduce duplication.♻️ Extract helper
+ /** + * Fetches all rows from a result set and maps them to entities. + * + * `@return` PushSubscriptionEntity[] + */ + private function fetchAllEntities($result): array + { + $db = $this->configuration->getDb(); + $subscriptions = []; + $row = $db->fetchObject($result); + while ($row) { + $subscriptions[] = $this->mapRowToEntity($row); + $row = $db->fetchObject($result); + } + return $subscriptions; + }Then each method simplifies to:
return $this->fetchAllEntities($result);Also applies to: 171-195, 202-221
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (3)
37-47: VAPID public key endpoint returns key even when push is disabled.When
$webPushService->isEnabled()isfalse, the endpoint still returns the VAPID public key (which may be empty or a stale value). Consider returningnullfor the key when push is disabled to avoid leaking configuration details unnecessarily and to give the client a clearer signal.♻️ Suggested tweak
return $this->json([ 'enabled' => $webPushService->isEnabled(), - 'vapidPublicKey' => $webPushService->getVapidPublicKey(), + 'vapidPublicKey' => $webPushService->isEnabled() ? $webPushService->getVapidPublicKey() : null, ], Response::HTTP_OK);
63-66:FILTER_SANITIZE_SPECIAL_CHARSmay mangle Web Push key material.
FILTER_SANITIZE_SPECIAL_CHARSHTML-encodes characters like&,',",<,>. While base64url-encoded keys ([A-Za-z0-9_-=]) shouldn't typically contain these, using an HTML-oriented sanitizer on cryptographic key data is semantically wrong and fragile. A validation filter (e.g., regex allowlist for base64url characters) would be more appropriate than a sanitizer that silently transforms input.
37-37: Route naming usesapi.public.*/api.private.*— deviates from conventions.The coding guidelines state frontend routes should follow
public.{resource}.{action}, and learnings indicate API routes should followapi.{resource}.{action}. The current names (api.public.push.vapid-public-key,api.private.push.subscribe, etc.) blend both patterns with an extrapublic/privatequalifier. If this is an intentional convention for distinguishing authenticated vs. unauthenticated API endpoints in this project, it should be documented. Otherwise, align with one convention.As per coding guidelines, "Frontend routes should follow the naming convention: public.{resource}.{action}". Based on learnings, "API routes should follow the naming convention: api.{resource}.{action}".
Also applies to: 52-52, 94-94, 126-126
phpmyfaq/src/phpMyFAQ/Notification.php (1)
307-324: Method exists and is properly typed — optional guard remains valid.
User::getSuperAdminIds()at line 970 in User.php is correctly defined aspublic static function getSuperAdminIds(Configuration $configuration): arrayand properly returnsint[]. The usage at line 315 is correct.The optional guard against empty
$adminUserIdsremains a reasonable optimization. If both no category owner ($userId <= 0) and no superadmins are found,sendToUsers()is called with an empty array, which may result in unnecessary processing. Consider adding:if (empty($adminUserIds)) { return; }This prevents calling the web push service when there are no recipients.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 40-89: The save method in PushSubscriptionRepository uses a
non-atomic SELECT→UPDATE/INSERT sequence leading to race conditions; change it
to an atomic upsert using the table's UNIQUE(endpoint_hash) constraint: replace
the SELECT + conditional UPDATE/INSERT in save(PushSubscriptionEntity $entity)
with a single atomic statement (e.g. INSERT ... ON DUPLICATE KEY UPDATE or
equivalent for the DB abstraction) that updates user_id, endpoint, public_key,
auth_token, content_encoding, and created_at when endpoint_hash already exists;
if the DB abstraction does not support that SQL, catch the
duplicate-key/constraint violation from the INSERT into $this->table (using the
computed $endpointHash) and then perform a safe UPDATE of the existing row to
avoid returning false on a benign race. Ensure you reference and update the same
fields currently used in $updateQuery and $insertQuery.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)
154-160: Consider extracting the repeated fetch loop into a private helper.The fetch-object-while-loop pattern is duplicated across
getByUserId,getByUserIds, andgetAll. A small private method would reduce repetition and centralize the mapping logic.♻️ Suggested helper extraction
+ /** + * `@return` PushSubscriptionEntity[] + */ + private function fetchAll($result): array + { + $subscriptions = []; + $row = $this->configuration->getDb()->fetchObject($result); + while ($row) { + $subscriptions[] = $this->mapRowToEntity($row); + $row = $this->configuration->getDb()->fetchObject($result); + } + return $subscriptions; + }Each caller then becomes:
- $subscriptions = []; - - $row = $db->fetchObject($result); - while ($row) { - $subscriptions[] = $this->mapRowToEntity($row); - $row = $db->fetchObject($result); - } - - return $subscriptions; + return $this->fetchAll($result);Also applies to: 186-192, 212-218
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 65-72: The try/catch in PushSubscriptionRepository around the
$db->query($insertQuery) currently catches the imported phpMyFAQ\Core\Exception,
so native DB driver errors escape; replace the catch(Exception) with a catch of
the global error type (e.g., catch(\Throwable $e) or catch(\Exception $e)) so
database constraint violations are intercepted and execution can fall through to
the UPDATE branch, and ensure the change doesn't conflict with any existing
Exception import.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)
157-165: Consider extracting the fetch loop into a private helper.The fetch-and-map loop is repeated verbatim in
getByUserId,getByUserIds, andgetAll. A small helper would reduce duplication:Example helper
private function fetchAll($result): array { $subscriptions = []; $row = $this->configuration->getDb()->fetchObject($result); while ($row) { $subscriptions[] = $this->mapRowToEntity($row); $row = $this->configuration->getDb()->fetchObject($result); } return $subscriptions; }Each caller then becomes:
$result = $db->query($query); if ($result === false) { return []; } - $subscriptions = []; - $row = $db->fetchObject($result); - while ($row) { - $subscriptions[] = $this->mapRowToEntity($row); - $row = $db->fetchObject($result); - } - return $subscriptions; + return $this->fetchAll($result);Also applies to: 189-197, 215-223
Summary by CodeRabbit
New Features
Infrastructure
Localization
Tests / Quality
Chores